Skip to content

Starter-kit authoring loop, one KPI dashboard, reverse-engineer references (PRD-750/751/735) - #575

Merged
jonit-dev merged 30 commits into
pr563-followupfrom
prd-750-kit-authoring-loop
Oct 27, 2026
Merged

jonit-dev merged 30 commits into
pr563-followupfrom
prd-750-kit-authoring-loop

Conversation

@jonit-dev

@jonit-dev jonit-dev commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

What is this PR about?

A loop that builds one starter kit per game genre, then has a fresh "user" agent build a game from it. After each kit, the loop measures what went well and badly and fixes the authoring layer before the next kit.

Why does it matter?

Before the release, "make me a game like X" must give a good first build on few tokens. Nothing measures that today, and each kit lane learns on its own.

Plan (3 PRDs, one epic PR, merged at the release)

  • PRD-750: the kit authoring loop. Each iteration has an author step (with reverse-engineer references), a cold sandbox build, 4 KPIs and a retro row in docs/kit-loop/ledger.json. The stop rule is the ranked genre list being done, or 2 iterations in a row with no KPI gain.
  • PRD-751: one dashboard. pnpm perf:live became pnpm dashboard. It gets CI-speed, asset-perf and kit-loop tabs, and the two static index.html pages go.
  • PRD-735: reverse-engineer references at scale. threenative-game-refs is renamed to ThreeNativeHQ/threenative-reverse-engineer-mcp. The PRD adds the Tier-2 index and the Tier-1 classics in the loop's genre order.

KPIs

KPI Target vs iteration-1 baseline
K1 cold-build cost (tokens, minutes, turns) -40% tokens
K2 first-build quality (scenarios, frame budget, visual judge) 100%, budget met, CLOSE or better
K3 friction (search misses, reinvention, wrong API, retries) -50%
K4 AI judge play grade (1-5) 4 or higher

Done

  • ✅ Owner interview: the decisions are in each PRD's ## Decisions section.
  • ✅ The three PRDs are filed. prd:progress exits 0 on all three, and pnpm check:docs exits 0.
  • ✅ PRD-751 (c2ab8c579, f799f51ab): pnpm dashboard is the one KPI hub; the two static index.html pages and the loops' report/serve commands are gone.
  • ✅ PRD-735 AC-0 (8b8083e83): references come from ThreeNativeHQ/threenative-reverse-engineer-mcp; a "reverse engineer" request leads with the reference tools.
  • ✅ PRD-750 Phase 1 (f799f51ab): pnpm kit:loop record <dir> writes K1-K4 and the retro to docs/kit-loop/ledger.json; a missing input fails closed.
  • ✅ PRD-735 AC-1 and AC-3 (e0b8e1867): the Tier-2 import (3,287 rows, byte-identical on a second import) and the OoT Tier-1 cards, data-repo validate green.
  • ✅ PRD-750 AC-4: ledger row 1 is the baseline; rows 2 and 3 are cold SM64 builds that picked the collectathon kit and passed all 22 gates (cost p95 10.03 and 12.59 ms against 16.7 ms).
  • ✅ Kit-loop retro fixes, each red-green: a failed cost bound names foreign Playwright GPU processes (a4d5345ef); the scaffold line says start the agent in the project, with a command-line engine call (42cbf971c, c5bc052e7); --scenario takes {a,b} (bac933cdc); read AGENTS.md once (59d741907); review fixes (620e61917).
  • ✅ Authoring-layer regression guards (55572d626): every shipped kit word routes "make me a game like " to its own kit; the core shim answers one engine call from outside the project and fails closed. With the per-fix specs above (frame-budget, scaffold completion, server command line, bridgeClient braces), each kit-loop win has a test that goes red if it regresses.
  • ✅ Ledger: K1 97,840 → 44,483 → 47,107 tokens; K2 21/22 → 22/22 → 22/22, judge CLOSE; K3 7 → 2 → 0; K4 4 → 4 → 4.

Still open

Checklist

  • PRD-751 AC-1 to AC-5 — pnpm dashboard hub with CI speed, Asset perf and Kit loop tabs; PRD-751 is in done/
  • PRD-735 AC-0 — repo transferred and renamed; the old release URL redirects
  • PRD-735 AC-1, AC-3 — Tier-2 import and Tier-1 cards
  • PRD-735 AC-2, AC-4, AC-5
  • PRD-750 AC-1 to AC-2 — pnpm kit:loop record and the Kit loop tab
  • PRD-750 AC-4 — baseline row 1; gate-green cold builds rows 2–3
  • PRD-750 AC-3, AC-5 to AC-7

…1) and the reverse-engineer rename (PRD-735)

Owner interview 2026-10-26: one iteration = in-repo kit author + cold sandbox build, retro on both
transcripts; KPIs K1 cold-build cost, K2 first-build quality, K3 friction, K4 AI play grade; ranked
genre list; PRD-735 lands first and renames threenative-game-refs to threenative-reverse-engineer-mcp;
perf:live becomes the single pnpm dashboard; one epic PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jonit-dev jonit-dev added the prd:0% PRD implementation has no verified phase yet label Oct 27, 2026
…Asset perf tabs

perf:live becomes pnpm dashboard. The CI speed and Asset perf tabs render their
ledgers on each load (missing = pending, malformed = invalid). The two static
docs/*/index.html pages, the loops' report/serve commands and their consumers go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-751 Phase 1 landed (c2ab8c579): pnpm dashboard is the one KPI hub. CI speed and Asset perf are tabs that render their ledgers on each load; the two static docs/*/index.html pages are gone.

AC-3 proof: headed Chromium under Xvfb, 5 tabs, 0 console errors, same nav on each tab.

Epic scoreboard Midway Shadows vs Unreal
CI speed Asset perf

@jonit-dev jonit-dev added prd:25% PRD phase 1 verified prd:0% PRD implementation has no verified phase yet and removed prd:0% PRD implementation has no verified phase yet prd:25% PRD phase 1 verified labels Oct 27, 2026
jonit-dev and others added 2 commits October 26, 2026 17:32
…engineer-mcp

The data repo moved and was renamed (the old release URL redirects). The engine
MCP reads the new release and files contributions there. A request that asks to
reverse engineer a game leads with the three reference tools, and their
descriptions carry the words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…oop tab

scripts/kit-loop.mjs turns a cold-build dir (transcript, gates, friction, play
grade, retro) into one ledger row with K1-K4; any missing input fails closed.
Row 1 is the baseline and sets the K1/K3 targets. The hub renders
docs/kit-loop/ledger.json as the Kit loop tab, and the native-performance-loop
skill now adds a hub tab instead of a per-campaign page. PRD-751 is done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@jonit-dev jonit-dev added prd:25% PRD phase 1 verified and removed prd:0% PRD implementation has no verified phase yet labels Oct 27, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-750 Phase 1 + PRD-751 Phase 2 landed (f799f51ab): pnpm kit:loop record <dir> and the Kit loop tab. PRD-751 is done.

Kit loop tab, live (empty ledger, 0 console errors) 2-row FIXTURE render (charts against baseline and targets)

PRD-750 Phase 2 waits for PRD-749 (kit base) to land on pr563-followup.

jonit-dev added a commit that referenced this pull request Oct 27, 2026
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-750 iteration 1: baseline ledger row 1 recorded (pnpm dashboard → Kit loop tab)

KPI Row 1 (collectathon, cold "make me a game like Super Mario 64")
K1 cost 97,840 tokens (input + output + cache writes), 37 turns, 19 min
K2 quality 21/22 scenarios green; presented p95 17.7 ms vs 16.7 ms ❌ (loaded host); visual judge CLOSE ✅
K3 friction 7 (1 wrong API, 6 retries of the red performance gate)
K4 play grade 4 (fresh Opus judge, evidence-based)

Top frictions for iteration 2: the performance gate goes red under foreign GPU load while the capture lock reports no competitor; the engine MCP is absent because .mcp.json lands after the session starts; the agent reads the 45 KB CLAUDE.md + AGENTS.md mirror twice.

Still open: AC-3 waits for the PRD-749 kit base (only its Phase 1 contract landed; collectathon is red on camera-sees-subject and hud-clean). AC-4 waits for one quiet-host run of the performance gate.

Also in this push: PRD-735 AC-1 (3,287-row Tier-2 import) and AC-3 (Ocarina of Time, 17 reviewed cards) are ticked, with the proof in the data repo.

…hem and says rerun, not cut the game (PRD-750 retro friction 1)

Kit-loop row 1: the cold agent spent ~25 turns lowering the high tier's look to pass a
performance gate that two foreign Playwright GPU processes were loading, while the capture
lock printed 'no competing runner detected'. foreignGpuProcesses() reads /proc for Chromium
GPU processes outside this run's tree; the failure stays a failure (fail closed), its message
names them and its suggestion says to rerun when they exit.
…gent in the project so .mcp.json loads the engine MCP (PRD-750 retro friction 2)

Kit-loop row 1: the cold agent scaffolded from its session folder, so the engine MCP that the
project's .mcp.json wires never loaded and it searched no capability (it grepped agent-docs).
…hat never loaded .mcp.json (PRD-750 retro friction 2)

node node_modules/@threenative/core/mcp/engine.mjs <tool> '<json>' prints the tool text (exit 0)
or names the error (exit 1). The scaffold's last line names it beside 'start your agent in the
project'. Red-green: server.spec 'command line', scaffold.spec completion.
…laytest.json) (PRD-750 row 2 retro)

Kit-loop row 2: the cold agent quoted a brace list and got TN_PLAYTEST_SCENARIO_UNREADABLE, then
reran the whole suite. segmentMatches now reads one level of {a,b}. Red-green: bridgeClient.spec.
…es AGENTS.md, read one (PRD-750 retro friction 3)

Rows 1 and 2 both read the 45 KB CLAUDE.md + AGENTS.md pair, then AGENTS.md again. Red-green:
scaffold.spec completion.
…CLOSE, K3 2, K4 4) names row 1's fixes; AC-5 notes
@jonit-dev

Copy link
Copy Markdown
Contributor Author

PRD-750 iteration 2: ledger row 2 recorded

TLDR: with row 1's retro fixes in the tarballs, the same cold SM64 prompt cost 55% fewer tokens, finished in 15 turns, and passed all 22 gates. The agent changed no code.

KPI Row 1 (baseline) Row 2
K1 tokens · turns · minutes 97,840 · 37 · 19 44,483 · 15 · 7
K2 scenarios 21/22 22/22
K2 performance gate (cost p95, budget 16.7 ms) 17.27 ms, red, loaded host 10.03 ms, green, load 14
K2 visual (fresh Opus judge) CLOSE CLOSE (NEUTRAL vs row 1)
K3 friction 7 2
K4 play grade 4 4 (Hero.ts unchanged)

Fixes that landed before row 2's author step:

  • a4d5345ef: a failed cost bound names the foreign GPU processes and says rerun before changing the game.
  • 42cbf971c + c5bc052e7: the scaffold's last line says start the agent in the project. engine.mjs <tool> '<json>' answers one engine call without .mcp.json.

Row 2's retro fixes landed for row 3:

  • bac933cdc: --scenario takes a brace list.
  • 59d741907: the scaffold line says CLAUDE.md copies AGENTS.md, so read one.

⚠️ Owner decision: K2 "budget met" reads TN_FRAME_BUDGET presented p95. On the runner's private Xvfb that is display cadence: 17.5 ms with a game that costs 10.03 ms. So K2 cannot pass there. Options: judge the gate's cost p95, or measure presented p95 on a real display.

Label: prd:25% (unchanged; AC-5 stays open while rows 3..N run).

… AC-4 (rows 2-3 gate-green cold builds on the kit); rows 4..N blocked on the PRD-749 kit base
- the scaffold's engine fallback names the project's absolute path (a session started in the
  parent folder got MODULE_NOT_FOUND from the relative one)
- foreign GPU processes are Playwright Chromium only (an editor's or desktop browser's GPU process
  made every real over-budget failure read as contended)
- an unmeasured cost is never called contended (the suggestion said rerun beside 'cannot be judged')
Red-green: frame-budget.spec, scaffold.spec completion.
- every lookup word of every shipped kit routes 'make me a game like <word>' to its own kit first
- the core engine shim answers one tool call from a folder outside the project (exit 0,
  collectathon first) and an unknown tool exits 1 with no stdin hang
…budget met and a CLOSE verdict

The Kit loop tab marked K2 met on rows 2-3 from 22/22 scenarios while their frame budget was over.
Red-green: kit-loop.spec.
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Kit loop tab with ledger rows 1–3 (headed Chromium, 0 console errors, 442f48d4a)

Kit loop tab, rows 1-3

  • ✅ K1 97,840 → 47,107 tokens, K3 7 → 0, K4 4: met.
  • ⚠️ K2 reads open. It now needs all scenarios green, the frame budget met and a CLOSE verdict. Before 442f48d4a, the badge said "met" from the scenario share alone. The budget part waits on the owner's metric call (presented p95 on Xvfb is display cadence).

jonit-dev and others added 5 commits October 26, 2026 21:55
…legated); rows 1-3 recomputed

Presented p95 on the runner's private Xvfb is display cadence (17.5 ms with a 10 ms game). gates.json
now carries costP95Ms (fails closed when missing); rows 2-3 meet the budget at 10.03 and 12.59 ms,
row 1 stays over at 17.27 ms. Red-green: kit-loop.spec.
… the kit-loop KPI id in the full-history gitleaks scan
#579)

* chore(refs): point references at ThreeNativeHQ/threenative-reverse-engineer-mcp [skip ci]

The data repository moved from jonit-dev/threenative-game-refs to
ThreeNativeHQ/threenative-reverse-engineer-mcp. The old name still redirects,
but search, the contribution command and the docs should name the canonical
repository. The default URL already tracks `releases/latest`, so published
data keeps flowing without a version pin.

`packages/core/mcp/engine-server.mjs` is regenerated from the engine-mcp
build; its only delta is the same two constants.

* fix(refs): read the catalog fields a reference release now carries [skip ci]

`index.json.gz` game rows gained `kind`, `rating`, `genre` and `subgenre`, and
the strict row whitelist rejected the whole release with `TN_REFERENCES_MALFORMED`
— the engine could not read any release published after the catalog grew those
columns. Search never reads them, so the whitelist accepts them and drops them;
a field the engine does not use must not gate a data release.

Templates also name classic-game mechanics references next to capability search in
the authoring skill list, so a scaffolded agent sees both discovery paths.

Proof against the published v0.2 release:
`THREENATIVE_REFERENCES_URL=https://github.com/ThreeNativeHQ/threenative-reverse-engineer-mcp/releases/download/v0.2/ pnpm exec vitest run packages/engine-mcp/__tests__/references.spec.ts`
(28 passed, live release download included).
…ing-loop

# Conflicts:
#	packages/create-threenative/__tests__/scaffold.spec.ts
…ps with a ready reference search (PRD-750, sandbox-4 made no engine call)
… the top mechanics; a ready command alone was skipped (PRD-750 sandboxes 4-5)
…-authoring-loop

# Conflicts:
#	packages/create-threenative/__tests__/scaffold.spec.ts
…-authoring-loop

# Conflicts:
#	packages/create-threenative/__tests__/scaffold.spec.ts
@jonit-dev

Copy link
Copy Markdown
Contributor Author

Merging into pr563-followup with base reds only (owner: "just get this merged", "decide for me").

Full run 40487627667 on 163d33537. Every red maps to base content, not to this PR's own diff:

Red Cause (base)
integration / auto-exposure (warm + cold) 4a8cb1cd4 PRD-747 black bucket; bisect and evidence in #574 (issuecomment-6436598970)
budgets shooter AGENTS.md table cells point at Enemy.ts:842/848, Rifle.ts:136; b713deccc (PRD-748) moved those lines
test-unit 1/4 check-native-coverage stale source digest; base runtime-native changes, this PR touches no runtime-native file
test-browser hot-reload.spec.ts HMR; this PR touches no core or HMR file
template puzzle / racing / rts / tower-defense / collectathon game state (pause, HUD boost, resources); this PR's playtest diff only adds a glob brace list and a contention note
template rain SwiftShader device loss, flaky (green in run 40460462458)

The run before the base merge (40476691039 on 8a1ff5456) had all of these green except auto-exposure, rain and two flakes. New since then on the base: check-native-shims rejects packages/core/src/worker.ts:584 MessageChannel (105f49228, PRD-752).

f71d036e1 merges the base again (33 more commits). Only the 16 template hashes changed on this side; scaffold + cli 80/80, capability-examples 2/2.

PRD-750 stays open at prd:25%: AC-3 and AC-5..7 wait for the PRD-749 kit base.

@jonit-dev
jonit-dev marked this pull request as ready for review October 27, 2026 11:45
@jonit-dev
jonit-dev merged commit 4e74eea into pr563-followup Oct 27, 2026
13 checks passed
jonit-dev added a commit that referenced this pull request Oct 27, 2026
…ranches

The merge-group secrets scan reads every fetched ref, so a verified false
positive on another open branch stops every queued PR. Same entries as
prd/temporal-upsampling bc25d08, plus the copy on pr563-followup.
github-merge-queue Bot pushed a commit that referenced this pull request Oct 27, 2026
…n + render thread on by default (PRD-613) (#577)

* docs(PRD-613): resume the render thread lane after placement

* docs(PRD-613): order ctest and TSan proof for the render thread

* fix(runtime-native): native asset loader reports core's asset cache (PRD-743 surface)

PRD-743 added IAssetLoader.cache, prefetch and startBackgroundFill, and the
playtest bridge's state sample reads cache.enabled. The native player's
loader had none of them, so every native playtest observation threw
TN_INSPECT_GAME_BRIDGE (reading 'enabled'), and a game passing
assets.cache or assets.backgroundFill threw TN_NATIVE_ASSET_OPTIONS_UNSUPPORTED.
The native loader now reports the cache as disabled with core's native
reason, and prefetch and the fill resolve at once.

Red-green: native_engine_player_imports red on the options refusal, green after.

* perf(PRD-613): batching holds a projecting decision while the scene's structure does

RenderDatabase::batch judged a projecting projection decision again every
frame: a full scene walk, content classes and the group maps, only to read
the rigs' verdicts. On Midway that was about 11% of main-thread samples at
cockpit and rear gunner. The decision now holds while hierarchyVersion and
structureStamp (moved from the bindings into projection/plan.h, shared with
hostProjectionReport) hold, and while every rig's lane key (lane reason,
similarity, mirror sign) holds; the 60-frame cadence still re-judges it.
TN_BATCH_DECISION_CACHE=0 (diagnostic) restores the old cadence for paired arms.

ctest: skinned_projecting_cadence red then green; skinned_crowd_cpu caught the
rig-scale and material cases; native-engine label 516/517 (target_graph is the
UI-overlay configure).

* perf(PRD-613): the render thread is on by default

Phase 3 passed on Midway at 99ad82d: main-thread CPU per frame falls by
3.4-7.5 ms per pose in three alternating pairs (render thread plus the held
batch decision against today's develop path), parity PASS in every run, no
steady-state fallback, visual judge NEUTRAL at all three poses.

TN_RENDER_THREAD=0 turns it off; a GPU timing run keeps it off, since it has
no synchronized timing snapshot (TN_RENDER_THREAD=1 with GPU timing still
fails closed). Red: a Midway flight with the variable unset on the binary
before this change reports no renderThreadCpuMs.

* docs(PRD-613): done; the default render thread passes its green flight

dflt-green (render thread on with no flag, after the switch) reports
renderThreadCpuMs in 4,574 frame lines, fallbacks 1, parity PASS; dflt-red on
99ad82d reports none. The PRD moves to docs/PRDs/done/. PRD-610 AC-16 stays
open for the owner: cull and project stay on the main thread.

* style: format scaffold.spec.ts comment indent (lint red on develop since #579)

* chore(PRD-613): refresh native coverage report for the render database and player changes

* ci: allowlist the kit-loop KPI field id false positive on #574/#575 branches

The merge-group secrets scan reads every fetched ref, so a verified false
positive on another open branch stops every queued PR. Same entries as
prd/temporal-upsampling bc25d08, plus the copy on pr563-followup.
jonit-dev added a commit that referenced this pull request Oct 28, 2026
…the merge-group commit that adds it has no SHA to pin

The merge queue stopped #574 on supply-chain: gitleaks generic-api-key on scripts/kit-loop.mjs:45, the KPI field id k2ScenarioPct, already verified as a false positive and pinned by fingerprint on #575's and #574's branch commits. The squash brings the file to develop in a merge-group commit whose SHA exists only once the queue builds it, so no fingerprint can name it. The inline allow suppresses that one line; the scan and its config are unchanged.

Checked with the pinned CI image (gitleaks dir on the file): 1 leak before, no leaks after.
jonit-dev added a commit that referenced this pull request Oct 28, 2026
…n, kit contract and worker mode (PRD-723/746/747/748/749/752) (#574)

* fix(runner): the jump clears a block

The apex was 7.2^2/38 = 1.36 m, so the hitbox's bottom (feet + 0.05 m) never
rose past a 1.5 m block's top: every jump over a block was a crash, and the
advertised space-to-jump did nothing useful. Take-off is now 8.5 m/s: a 1.9 m
apex, clear of the block for 0.44 s (4.8 m at the 11 m/s start speed).
Jump and hitbox constants are exported for the spec.

Red (__tests__/runner.spec.ts): clearance discriminant -3.26 (never clears).
Green: 1/1 passed.

PRD-747

* fix(runner): count a near miss only beside a real obstacle

The near-miss loop tested every lane of every chunk, not the obstacles in it:
a lane change on empty road, the first 48 m included, scored near misses and
shook the camera. Chunks now list their obstacles and the loop tests those
while the runner is level with one.

Red (playtests/near-miss.playtest.json): nearMisses 3 after three lane changes
in the first 44 m. Green: 0.

PRD-747

* fix(minimal): the follow camera stays in front of the arena wall (PRD-747)

render/camera.ts followed a fixed offset with no wall check, so with the
player's back to a wall the lens passed behind it and the wall filled the
frame (free play, frames 9-10). Layer: kit (src/render/camera.ts, framing
only; no colour or look value). followCamera now takes a WallProbe (the
collectathon pattern): a wall between the aim and the offset pulls the lens
in front of it at once, and the shortened arm climbs (the adventure kit's
rule) so the lens looks down on the player; it eases back out when clear.
Play.ts registers 'camera' with debug().hidden (a wall between player and
lens). Needs 62b225d79: before it, the probe hit the player's own capsule.

Red-green: playtests/camera-wall.playtest.json (walk back into the south
wall; hidden equals false) red before: hidden false -> true; green after:
pass. Visual judge (fresh read-only Opus, 2 headed WebGPU runs per arm,
NVIDIA Turing, ~/Downloads/pr574-prd747-cam/): IMPROVEMENT overall; at-wall
and walk IMPROVEMENT, spawn and away NEUTRAL; note: the top-down pose at the
wall foreshortens the mannequin.

* test(collectathon): drop the HUD 'covered' count that could not fail (PRD-747)

Play.ts set statePatch.hudFaded = under, then counted
'under && !statePatch.hudFaded': always 0, so enemies.playtest.json's
'covered equals 0' asserted the line above it. Layer: kit (proof only, no
behaviour change). The count and its assertion go; 'under >= 1' still proves
a Sunseed sat under the pill, and Hud.tsx fades on state.hudFaded.

Check: playtests/enemies.playtest.json passes in the scaffold after the
change.

* fix(sailing): the end panel names the loss, and its clock and wind hold where the run ended

Running out of wind was reported as 'capsized', and under the end panel the
elapsed clock kept counting and the wind kept draining. The HUD now reads the
passage's clock and wind as they stood when it ended; the sea keeps its own clock.

Red: fail.playtest elapsed 3.20 s two seconds after a capsize (bound <= 1.1);
float-after-the-run-ends outcome panel 'capsized' after the wind ran out.
Green: elapsed 1.00 s; outcome 'out of wind' (and 'capsized' after a capsize).

PRD-747

* fix(sailing): take the capsize test key out of the players' hint

The menu advertised 'C capsize test', a developer hook that ends the run. The
binding stays for playtests/fail.playtest.json and is commented as such; the hint
now reads 'WASD / arrows steer and trim'. Text only, no behaviour change.

PRD-747

* fix(puzzle): the V replay keeps the shove count and reports its bodies

After V the crates were rebuilt with their rest pose set to the post-V pose, so
'shoved' and 'push metres' read 0 for a vault the player had already pushed;
replayBodies was read after the rig was cleared, so it was always 0.

Red (playtests/replay-keeps-progress.playtest.json): pushedCrates 0 after V
(1 before it), replayBodies 0. Green: pushedCrates 1, replayBodies 3.

PRD-747

* test(create-threenative): re-pin scaffold hashes for the PR #574 ARPG review

The Three patch ships in every scaffold, so its shared-skin history fix moves all sixteen trees;
with the previous patch bytes only action-rpg moved (checked). create-threenative tests: 116
files, 1836 tests pass.

* feat(shooter): generated tiling surfaces on the town's procedural geometry (PRD-746)

Five greyscale tiles generated through scripts/reference.mjs
(google/gemini-3.1-flash-lite-image, 512 px WebP) and three derived normal
maps, 313 KB in all, recorded in bay-PROVENANCE.md. src/render/surface.ts
takes only each tile's structure (sample over its own mean), so the 7b546d6
neutral palette keeps its colour; a turned large-scale copy breaks the repeat,
wall feet darken, roughness follows the texture and the fine detail fades
with distance. Town surfaces stay standard node materials, so the high-tier
backlight still converts them; the plain grid materials remain the fallback.
Fresh Opus judge vs the step-1 frames: IMPROVEMENT (9 of 11 poses, deck
undersides NEUTRAL).

* fix(tower-defense): a pooled walker no longer respawns with a chilled health bar

Symptom: after a cryo tower, walkers in later waves showed the cyan 'chilled' bar
while moving at full speed. Root cause: src/enemies/Enemy.ts reset() cleared the
slow but kept the bar tint a chilled death left behind. reset() now restores
barFull. Enemy.barTintMatches + battle.barTintMismatches report the invariant.

Red-green: playtests/chill-tint.playtest.json (new; cryo + sentry, auto waves at 3x,
30 samples), headed WebGPU nvidia/turing. Before: components FAIL, barTintMismatches
up to 8. After: exit 0, 36/36 samples 0 through wave 4.

* docs(prd-748): plan performance by construction, the engine decides the fast path and fails closed (owner chose option 1)

* fix(rts): A arms attack-move without panning the camera

Symptom: pressing A to arm attack-move slid the view left, so the follow-up
click landed on different ground. Root cause: src/game.ts bound KeyA to both
'move' (camera pan) and 'attack'. The pan now uses the arrow keys only, as the
letter keys are orders.

Red-green: playtests/rts-attack.playtest.json now asserts state.cameraX stays
-70 at box-release and armed. Headed WebGPU nvidia/turing. Before: FAIL
resource.state.cameraX.atSteps (-71.2 after A). After: cameraX -70 at every
sample; the run's only red is TN_SCENE_WARNING under host load 65 (unverified,
a host-load reading, not this change).

* docs(prd-748): evidence from the kit perf inventory; Phase 1 gains stage attribution, the allocation guard and explore-path bounds

* fix(rts): the minimap draws units where they are and the real camera view

Symptom: every unit dot sat in the top-left 28 px of the tactical overview,
and the view box was a fixed-size square that did not follow zoom or the
isometric angle. Root cause: src/ui/Minimap.tsx drew minimapDots (tenths of a
cell) as raw pixels, and derived the view from cameraX/Z/zoom with the wrong
scale. Play.ts now publishes minimapView, the ground under the frame's four
corners in the same units, and Minimap draws dots and that quad scaled by cell.

Red-green: playtests/rts-build.playtest.json adds a visual region over the
fogged top-left corner of the map. Headed WebGPU nvidia/turing. Before: FAIL
visual.0.region.darkPixels 0.910 < 0.98. After: exit 0, ratio 1.0.

* docs(prd-749): one kit base and a kit contract, fix a bug once and prove it on every kit (owner ask)

* refactor(platformer): read the fox's leg by name, keep fox.ts under 200 lines (PRD-747)

looks.spec.ts caps each generated render file under 200 lines; 650e60724 took
render/fox.ts to 205. The legSwing getter leaves IFox: the left leg group is
named 'legL' and Fox caches it for standingSwing (NaN, so the idle assertion
fails closed, if the name goes). No behaviour change.

Checks: looks.spec.ts 21/21 pass; playtests/idle.playtest.json passes in the
scaffold; scaffold tsc, eslint and biome clean.

* test(minimal): runtime-cost sentinel passes followCamera a wall probe (PRD-747)

0a6ea3067 made the WallProbe a required followCamera argument;
template-runtime-cost.spec.ts called it with three. The sentinel now runs
both arms (clear way, and a wall at 1 m that pulls in and climbs) and still
expects zero vector clones or constructors.

Check: template-runtime-cost.spec.ts 7/7 pass.

* fix(puzzle): the HUD shows the player's progress, not the vault's physics telemetry

The top-left panel showed developer telemetry to players: seed, solid and phase
crate counts, at rest, push metres, blocked ticks, walked through, seal contacts,
and replay bodies/drift in exponential notation. It now shows the title and
'crates shoved', plus 'replay matched/drifted' after V. The telemetry stays in
the published state the scenarios read.

Visual judge (fresh Opus, headed WebGPU nvidia/turing, 2 runs per arm):
IMPROVEMENT overall; readability NEUTRAL, rest of frame NEUTRAL (no regression).
Frames: ~/Downloads/pr574-prd747-puzzle-hud/.

PRD-747

* docs(prd-748,749): default-on perf capabilities audit; agent docs describe the kit base and contract (owner ask)

* test(create-threenative): re-pin scaffold hashes for the PRD-747 kit fixes

Recomputed 2026-10-26 (PRD-747 kits): collectathon, minimal and platformer
gain play fixes and playtests; values measured by scaffold.spec.ts itself.

Checks: packages/create-threenative/__tests__ 116 files / 1836 tests pass;
scripts/check-core-boundary.ts passes.

* fix(rts): rolling the wheel away from you zooms in

Symptom: the mouse wheel zoomed the opposite way to the other kits and to the
genre: wheel up pulled the camera out. Root cause: src/scenes/Play.ts passed
the zoom axis (positive for a wheel rolled away from the user) straight to
zoomRts, whose notches grow the view height. The call now negates it.

Red-green: playtests/rts-zoom.playtest.json (new). Headed WebGPU nvidia/turing.
Before: FAIL resource.state.cameraZoom.atSteps, closer = 32.4 (expected 27.6).
After: exit 0, 30 -> 27.6 -> 30.

* docs(rts): list the rts-pause and rts-zoom scenarios

* fix(racing): a car the player stopped is parked, not beached

The stall rescue counted any car under 0.6 m/s for 1.2 s, with or without
throttle. A player who braked to a stop on the road was rescued every 1.2 s,
and the fourth rescue is a DNF: five seconds parked ended the race (found in
free play: DNF at 00:06.2). A stall now counts only while the engine pushes,
which is the pinned-against-a-hoarding case the rescue exists for.

Red (playtests/stopped-car-is-not-rescued.playtest.json): rescues 4,
raceStatus DNF after a 7 s stop. Green: rescues 0, RACING; rescue, outcome
and reverse scenarios still pass. Unit (racing.spec.ts): parked car
shouldRescue true -> false; driven stuck car still true.

PRD-747

* fix(rts): a finished match shows its result and can be played again

Symptom: at the end of a match the DEFEAT/VICTORY tag sat under the command
column's hint bar (bottom right), half hidden, and nothing could start a new
match short of reloading the page. Root cause: src/ui/Hud.tsx put the result
in the pause button's corner stack, and no intent restarted the game. The
result is now a centred panel with a 'play again' button; src/game.ts maps
the 'restart' intent to game.goto('play'), which resets state and the scene.

Proof: out-of-tree five-minute scenario (box select, gather, train, wheel,
arrow pan, attack-move, pause, all army, base; 17,000 ticks) until the AI wins,
then the button. Headed WebGPU nvidia/turing. Before: DEFEAT clipped under the
hint bar, no restart control. After: exit 0; result 'defeat' -> '', supplyUsed
0 -> 23, clock 04:01 -> 00:03. Not added to playtests/: it is 4x the longest
kit scenario.

* docs(rts): name the zoom axis sign by DOM deltaY

* PRD-749 phase 1: the kit contract suite, sightLine and hudAudit assertions

Layer: tooling (playtest package, template sweep). A bug found in one kit becomes one row in
packages/playtest/src/kit-contract.ts; scripts/kit-contract.ts runs every row on every kit from
test:templates and prints a kit-by-row matrix. Kits declare what rows read in
template-playtests/contract.json (never shipped to a game); a row a kit neither declares nor
opts out of with a written reason fails closed.

Rows: explore-clean, camera-sees-subject, hud-clean, no-hitch, foot-slip, restart-resets,
action-clip-completes, effect-follows-contact. Two new assertions back them: sightLine (camera
ray to the subject, sampled at the path cadence) and hudAudit (debug text and text contrast
from the DOM).

Red-green: sight-line.spec (10), hud-audit.spec (11), kit-contract.spec (7), scripts kit-contract.spec.
Browser runs of the rows are held; the per-kit red list follows.

* test(scaffold): re-pin the kart, puzzle, racing, runner and sailing trees

Recomputed 2026-10-26 (PRD-747 kits) from a clean committed tree: the play
fixes in this lane move those five scaffolds. The byte-stability test passes
(1/1) with the new values.

PRD-747

* test(create-threenative): re-pin the rts and tower-defense scaffold hashes

Recomputed 2026-10-26 (PRD-747 kits) by scaffold.spec.ts on this branch's tree.

* test(kart): motion blur off (owner: the kart seems to hang while driving)

* feat(core): AnimationPlayer.play takes a one-shot speed, like Godot's play(name, blend, speed)

Layer: engine. A one-shot always played at its authored rate, so the action-rpg roll (a 1.47 s
Roll clip) was cut at its 0.5 s dodge and blended back to the legs mid-dive, which read as a
weird jump. `speed` sets the rate of that play of a "once" clip; the next play returns to 1;
a loop keeps its stride-synced rate. A non-positive or non-finite speed throws.

Red-green: animation.spec "plays a one-shot at the requested speed..." (x 0.25 vs 0.5) and
"rejects a play speed..." red before, green after; animation, animation-rate and
animation-weighted specs 134/134 pass. Written by the DeepSeek arm from my red test; reviewed.

* fix(action-rpg): the roll plays its whole clip, travels and faces where it goes

Layer: kit. Player.dodge held the 1.47 s Roll clip for 0.5 s, so the legs took over mid-dive
(pelvis at 0.65 m, falling from a 1.13 m leap) and the roll read as a cut-off jump. A roll with
no key held did not move, and a roll toward a new direction played facing the old one, because
the heading is frozen while a one-shot holds.

The roll now holds 0.7 s and plays the whole clip inside it (Fighter.play `fit`, on core's new
one-shot `speed`), picks its direction on its first tick from the stick or the facing, turns the
figure to it and carries the body along it. The snapshot reports `facingDegrees`.

Red-green: playtests/roll.playtest.json, 4 failures before (moved 0.42 m, facing 180 not 270,
action not dodge at 0.6 s, second roll refused), green after; combat, swing and win green.
Written by the DeepSeek arm from my red scenario; reviewed (its hit-hold change reverted).

* feat(templates): the starter kits share one 0 EV exposure meter (PRD-747)

Core stays stock Unreal 5.8.3 at +1 EV (key 0.36, r.DefaultFeature.AutoExposure.Bias 1.0,
UE 5.8.3: Engine/Source/Runtime/Engine/Private/SceneView.cpp:196-199). The owner chose one shared
kit override: template-assets/kitExposure.ts sets key 0.18 with Unreal's -10..+20 EV100 range
around it, registered in SHARED_RENDER_SOURCES and stamped into adventure, collectathon, kart,
platformer, racing, runner, sailing and starter by pnpm stamp:template-render.

The shipped config equals the judged K arm (key 0.18, minStops log2(0.18)-20, maxStops
log2(0.18)+10): a fresh Opus judge read IMPROVEMENT vs CURRENT on 9 of 9 kits, 2 runs per arm,
headed WebGPU on nvidia/turing. Minimal (owner: CURRENT is best), rain, shooter, snow,
tower-defense, puzzle, rts and action-rpg keep their own exposure.

kit-exposure.spec.ts: each shared kit ships the canonical copy byte for byte and meters at key 0.18
on every tier; minimal, rain, snow keep 0.36 and shooter 0.14. Scaffold hashes re-pinned for the
eight kits.

* fix(action-rpg): the dungeon returns to the owner's 7b546d6 neutral look as named overrides of core

Owner 2026-10-26: "check exposure issue too, same as the shooter". The core-look migration made
the floor and walls read warm beige: sun 0xfff0dc x5.5, floor 0x98948e, sky 0.8/0.45, filmic at
metered exposure. Restored as named overrides on core's qualityPreset (`neutralLook`): ACES at
fixed exposure 0.62, the kit bloom and GTAO, 4x MSAA; sun 0xfff1e0 x4.5, floor 0xb4b1ae,
horizon 0xacb1c1, full sky range. The effective preset equals the 7b546d6 kit's on every tier
(only the unused SSGI/SSR radii differ). Later material fixes (props) stay.

Meter on CURRENT (2 runs, nvidia/turing): 0.002 to 0.092 stops, luminance 0.34-0.36, no pumping;
startup frames step 0.60 -> 0.52 mean brightness about 1.3 s after the reveal. RESTORED: one step.
Judge (fresh Opus): RESTORED vs CURRENT IMPROVEMENT in all three rooms (yellow cast removed);
RESTORED vs PRE-MIGRATION MATCH room 1, CLOSE rooms 2-3 (camera pose offset only).
kit-look-defaults.spec asserts the overrides: 87/87 pass.

* fix(core): a stride-synced walk in the 32-128 px band evaluates every frame

Owner 2026-10-26: "leg movement and actual movement looks weird too". The auto update rate held
the top-down action-rpg hero (about 60 px) at band rate 2 while it ran: the pose evaluated every
other frame on a body that moves every frame, so the planted foot rode with the body. Measured on
the walk scenario: 0.97 m of foot travel per metre at band rate, 0.50 at rate 1 (nvidia/turing).
A moving, stride-synced walk in that band now evaluates every frame; a still idle, smaller rigs and
explicit updateRate keep their rate. Red-green: animation-rate.spec "evaluates a stride-synced
walk every frame in the 32-128 px band" (red: rate 2), 156/156 across the animation specs.

* fix(kart): every kart pitches with the road between samples, so no kart flutters (PRD-747)

Owner: "KART IS SHAKING WHILE MOVING" and "all karts shaking/fluttering not only player".
Cause: render/kart.ts set each model's pitch from course.slope[kart.index], the slope of the
nearest 1 m centre-line sample, so the pitch stepped once per metre for every kart.
Fix: Course.slopeAt interpolates the slope between samples, as heightAt already does for height.

Red: smooth-drive.playtest.json, shakePitch player 1.3088 deg and cpu.1 1.3603 deg per frame
(bound 1). Green: player 0.3253, cpu.1 0.1781; all assertions pass.
TN_TEMPLATE_ONLY=kart test:templates: 12 of 13 pass, smooth-drive included. Only
production-performance fails (GPU p95 37.68 ms on a loaded host, hardware adapter).
Scaffold spec 74/74, kart hash re-pinned. Fix by the DeepSeek arm, reviewed by the lead.

* fix(shooter): no motion blur, a body that eases to speed and stops, an eye that climbs stairs smoothly (PRD-746)

Owner: "Blur is even weird, character movement is weird", then "Feel free to tweak that motion
blur or disable it. check unreal codebase, search on github for some decompilation for a decent FPS".

- Motion blur off on every tier. Core's is Unreal's (UE 5.8.3: Engine/Source/Runtime/Engine/
  Private/Scene.cpp:646-648) and scales with frame time (Renderer/Private/SceneVisibility.cpp:
  5650-5658), so a mouse turn smeared the frame, and the skinned hands took the camera's motion
  (Unreal keeps them sharp with first-person velocity, Shaders/Private/VelocityShader.usf:74-82).
- movement.ts: friction, then acceleration toward the wished speed (Quake III bg_pmove.c
  PM_Friction / PM_Accelerate mechanics, no code copied). The old code set the velocity in one tick.
- The eye climbs a stair step at a fixed rate instead of jolting with the body (the Half-Life
  view smoothing). The rifle bob follows the body's speed.

Proof: shooter-movement.spec.ts plus related specs 166/166. Playtests on NVIDIA Turing, headed:
movement-eases and stairs-smooth-the-eye exit 1 on the old code and exit 0 on the new one
(speed 3.67 while sliding to a stop, viewRisePeak 0.0291 <= 0.064). The old-code failures are
missing observations (speed and viewRisePeak did not exist), so the old values are not measured.
TN_TEMPLATE_ONLY=shooter test:templates: 35/36 pass; only performance fails (p95 36.19 ms on a
loaded host). Visual judge for the blur change: not run.
Written by lane-shooter-feel (stopped), verified by the DeepSeek arm, reviewed by the lead.

* Revert "fix(physics): a ray from inside a capsule skips the capsule (PRD-747)"

This reverts commit 62b225d79080725420a60042c10b26c5c16cae39.

* test(scaffold): re-pin the 16 kit trees after merging the PR #574 kit lanes

* Reapply "fix(physics): a ray from inside a capsule skips the capsule (PRD-747)"

This reverts commit 60aca2c86971c7d9667fe47bd99b9a27d12bfe87.

* fix(runtime-native): a native ray from inside a capsule or ball skips it, as on web (PRD-747)

The native host skipped a solid only at time of impact 0 with a zero normal. Rapier gives a box
a zero normal there but a ball or capsule the reversed ray, so a ray from inside a character's
capsule hit the capsule itself. Native now skips on toi 0 alone and reports a zero normal for a
hit at toi 0, the same as packages/physics (62b225d79). Containment was rejected: Rapier 0.19.3
containsPoint is true on the boundary, so it cannot separate "on the surface" from "inside".
Red: cargo test 23 passed, 1 failed (capsule/ball case, lib.rs:2741). Green: 24 passed.
Web: a ball case added; packages/physics vitest 29 files, 240 tests pass.

* chore(pr574): format the merged kit-contract files, fit create-threenative AGENTS.md under 100 lines, sync mirrors, count 19 platformer playtests

* test(pr574): re-pin 5 kit hashes after the biome format pass

* test(native-engine): the TRAA invariant-scope test counts the per-shape velocity program; refresh the native coverage record (PRD-746)

* fix(shooter): the hands walk on Walk and sprint on Run at their authored rate, with no procedural bob on top (PRD-747)

* PRD-752: run web games in a worker so the page cannot stall them

Files Cycle v5 of PRD-573 as its own PRD (worker mode, Wasm threads),
records the owner's 2026-10-26 worker HUD decision and clears the
PRD-573 blocker.

* profile:wasm-page --load runs a page under a stated CPU load (PRD-752)

Keeps N busy-loop processes alive for the whole page run and stamps the
count on the report, so a loaded-host baseline is reproducible.

* docs(prd-748): the engine owns the sim budget, query cost, tick significance and HUD fields, from the shooter profile

* docs(prd-573): Phase v2.7, the web bridge's JS side under 2% of the frame (owner request, shooter profile)

* Core reads window and document only through platform.ts; loaded-host baseline (PRD-752)

- check-core-boundary type-checks packages/core/src against the webworker
  lib and fails on any value read of a global a worker lacks outside
  platform.ts (red on 19 reads in 5 files, green after the move).
- platform.ts gains hostWindow/hostDocument/hostResizeObserver; audio,
  game, hot, renderer and viewport use them. The renderer now throws
  TN_RENDERER_NO_CANVAS instead of a ReferenceError with no document.
- profile:wasm-page reports main-thread busy percent.
- PRD-752 phase 1 baseline and v5 KPIs recorded; owner scope decision.

* UI bridge loopback over a MessageChannel behind ?tnUiLoopback=1 (PRD-752)

Both ends stay in one realm but every frame arrives a task later, the
hop a game in a worker adds. Installed as the host globals by whichever
end connects first, so the game and the HUD do not change.

* fix(kart): a spun or tumbled kart lifts by its own dip instead of rotating half under the road (PRD-747)

* PRD-752 phase 2 done: native desktop gate and starter loopback proof

* fix(ui): DebugOverlay reads the registry snapshot only while it is open (PRD-748)

A snapshot calls every entity's debug(). The closed panel polled it every 100 ms in every dev build,
and the shooter's soldiers measure bone contacts there: 63% of sampled main-thread time in a
DevTools trace. Red: the new overlay spec saw snapshot() called with the panel closed.

* perf(shooter): soldiers check the grip on the rifle's own box, sight rays test only the boxes they cross, HUD panels read their own fields (PRD-748)

Paired profile, one scaffold, 2 runs per arm, RTX 2080: main thread busy 937/956 -> 840/769 ms/s;
Enemy.update, boneContact and React re-renders leave the top of the profile. Shooter suite 36/36.
The exact boneContact walk still runs whenever the cheap gap exceeds the 5 cm grip bound.

* fix(ui): DebugOverlay reads the registry snapshot only while it is open (PRD-748)

A snapshot calls every entity's debug(). The closed panel polled it every 100 ms in every dev build,
and the shooter's soldiers measure bone contacts there: 63% of sampled main-thread time in a
DevTools trace. Red: the new overlay spec saw snapshot() called with the panel closed.

* docs(prd-748): the loading cover hides page UI by default; debug-only work costs nothing while off

* Engine side of the worker boot: platform hook, worker engine bundle, named WebGPU failure (PRD-752)

A scratch starter booted in a worker on an OffscreenCanvas on the Wasm engine.
These are the engine-layer fixes that spike needed:

- core: defineGame takes a platform source a worker host installs
  (__tnGamePlatform) when the portable entry names none.
- core: TN_NO_RENDERER names the WebGPU failure instead of swallowing it.
- create-threenative: worker bundles get the engine resolver and ES output.
- runtime-native: the web module is built for web and worker environments.
- three-native: the renderer tolerates an OffscreenCanvas (no setAttribute).
- PRD: the spike result and the phase 3 design.

* fix(kart): models draw between fixed ticks, a hit costs 8 box corners not a vertex walk, and the HUD names the item key (PRD-747, PRD-748)

- Judder: ctx.interpolate(model.root), as the camera already is; a model that only stepped stood a
  tick ahead of the chase camera (0.44 m at speed). Owner confirmed on screen: 'kart lgtm'.
- Hang: the sink meter walked every vertex of a hit kart each fixed step (105 ms). DevTools, 15 s
  held throttle with a hit: red 19 frames over 100 ms, green 0 frames over 50 ms (max 33 ms).
- Items: E / X shows under the held item; new item-key playtest presses E for the player
  (boxes 0 -> 1, used '' -> 'nitro'). Before it only the autopilot ever used an item.
Kart suite 14/15; production-performance (GPU p95 35-42 ms vs 16.7) was red on this lane before
these changes and stays open.

* Worker game host: @threenative/core/worker runs a game in a worker on an OffscreenCanvas (PRD-752)

serveWorkerGame (worker) and startWorkerGame (page) speak a small message
contract: a ready handshake before the canvas transfer, the platform source
the game boots on, page-relative URLs, forwarded keyboard/pointer/wheel input
in canvas coordinates, the UI bridge on its own port, and the playtest bridge
mirrored onto the page as async calls. 9 linked-pair tests.

* Worker game audio: the page's AudioContext plays a worker game's sound (PRD-752)

A worker has no Web Audio API. The worker gets a stand-in AudioContext whose
nodes are handles; calls and property writes cross a MessagePort in one batch
per task and run on the page's real graph, so three's audio classes and
AudioBus work unchanged. AudioBus finds its gesture target in a worker, canvas
gestures reach it as they would by bubbling, and a page gesture resumes the
page's context. 7 remote-audio tests, 2 worker-host tests, 1 platform test.

* feat(quality): a ThreeNative ESLint plugin: tree walks and GPU resources in frame callbacks are errors, useUiState() without a selector warns (PRD-748)

* fix(engine): sibling renderers no longer hand a texture change back and forth every frame (PRD-748)

A renderer that saw a sibling's texture change bumped the shared generation again, so the next
renderer saw a change too. Once one texture changed, every render of every render target dropped its
map groups and render bundles for the life of the process: the shooter made 54 bind groups and 26
render bundles a frame. Seeing a change now only records it.

Red: renderer_caches_test sibling_texture_change failed on the old code. Shooter, 3 paired 10 s
profiles: onRender 420 to 354 ms/s, busy 931 to 813 ms/s.

* Worker game HUD: the UI bridge works when the game connects while its module loads (PRD-752)

The starter's game.ts calls game.ui.onIntent at top level, so in a worker the
game end connected before the page's port arrived and stayed on the in-process
broker: a blank HUD. @threenative/core/worker now installs the game end's host
globals when it loads in a worker, before the game module evaluates, and holds
frames each way until init attaches the port. 3 tests; headed GPU probe shows
the full HUD on the worker page.

* Starter: worker.html runs the same game in a web worker (PRD-752)

src/worker.ts serves the unchanged game.ts; src/worker-page.ts keeps the
canvas, input and the same GameUi on the page. index.html stays the default,
so one build carries both arms of the busy-page A/B. Headed GPU probe: same
frame and HUD as index.html.

* profile:wasm-page times frames on the game's own thread (PRD-752)

A worker page's requestAnimationFrame times the page, not the game. The probe
now reads frame intervals in the worker that holds the game's platform source,
else on the page, and names that thread in the result (gameThread). Main-thread
busy stays the page's CPU profile. 1 test.

* fix(shooter): shot vessels burst into drawn shards again; Breakables reads the shard's live material (game layer, template)

Root cause: createMaterialLighting swaps every scene mesh to a converted material copy made at enable
time (shard opacity 0), so the cached shard.material that Breakables mutated was no longer the
material drawn; shards wrote depth and GTAO but no colour. drawn(shard) reads mesh.material at use.

breakable-shatters gains breakables.drawnShards >= 7: red 0 (broken 1, liveShards 9), green 9.
Shooter scaffold hash re-pinned.

* Worker game pointer lock: mouse look works for a game in a worker (PRD-752)

An OffscreenCanvas cannot lock the pointer. In a worker its requestPointerLock
and exitPointerLock ask the page, which locks its own canvas within the click's
activation window and forwards each pointerlockchange with the page's verdict
as captured; a refused lock is reported as captured false. InputMap takes that
flag where no document exists, unchanged on a page. 2 tests.

* test(shooter): the idle hands case calls locomotion with its two arguments (typecheck was red since 7f0cd699b)

* docs(prd-748): a material the game holds stays the material that draws (shooter vase shards)

* profile:wasm-page times the frames the game presents (PRD-752)

A worker's requestAnimationFrame does not follow what the game presents, and
on a loaded page rAF ticks at 60 Hz while the game presents far fewer frames.
The probe now also times the game's getCurrentTexture calls (the gpuCalls
divisor) on the game's own thread, in both arms, as presented; it shims tsx's
__name in the game worker, and fails closed with a timeout. 1 test.

* Shooter: worker.html runs the same game in a web worker; PRD-752 decisions (PRD-752)

The shooter kit gets the starter's worker page: src/worker.ts serves the
unchanged game.ts, src/worker-page.ts keeps the canvas, input and GameUi.
Headed probe on the RTX 2080: town, soldiers, rifle and the live HUD with
minimap and clock draw from the worker. The PRD logs gaps 7 to 9, the
presented-frame timing, the loaded-host rule and the measured busy share
(starter 0.9 to 1.9% on worker.html against 45.7 to 86.9% on the page).

* Worker game playtest bridge: mirror it while start is held for the runner (PRD-752)

With a runner announced, the playtest plugin holds the boot until the runner
calls the bridge, so a mirror sent after start never reached the page and the
harness reported TN_PLAYTEST_BRIDGE_MISSING. The worker now posts each mirror
the moment the game installs the global. 1 test.

* fix(core): stride sync leaves a camera-carried rig at its authored rate; the shooter hands no longer play at 3x (PRD-747, PRD-748)

Layer: engine. AnimationPlayer stride sync (default on) read the camera carrying first-person
hands as ground covered: walking measured 5.6 m/s against the clip's 0.017 m/s of hand sway, and
a mouse turn swept the root 5.9 m/s, so the hands played at the 3x ceiling - the owner's 'seizure',
inconsistent because it followed how much he moved and turned. Every first-person game had it.

- Unset strideSync now skips a rig under a camera; strideSync: true still syncs it;
  stride.cameraCarried reports it, and the measured rate stays live (overridden: true).
- animation-camera-stride.spec: red 2/2 (rate 3, no report field), green 2/2; animation
  specs 10 files 178/178.
- Live shooter probe at 60 fps (core dist swapped in a served copy): walk rate 3 synced ->
  Walk 1.226 cycles/s vs 1.22 authored, synced false; idle while turning 0.76 = authored.
- Rifle.debug() reports handsRate; playtests/hands-authored-rate asserts it <= 1 while walking
  (red/green run queued on the GPU slot, result follows).

* feat(ui): page UI reads nothing behind the loading cover; useUiStateDuringStartup is the reported override (PRD-748)

* test(scaffold): re-pin the shooter hash for the hands rate guard

* fix(engine): a render target's renderer no longer draws an output image nothing reads (PRD-748)

* Worker is the default page for the starter and shooter kits (PRD-752)

index.html now runs the game in a web worker; main.html is the named override
that runs it on the page thread. The worker canvas forwards setAttribute to the
page canvas, so the harness reads the renderer's data-engine for capture
provenance (was TN_PLAYTEST_CAPTURE_PROVENANCE_MISSING). 1 test.

* fix(core): the camera cull stops walking the camera's children when the engine culls (PRD-748)

* test(shooter): hands-authored-rate measures the clip seconds the hands really ran, walking and firing

The first guard read the action's effective time scale, which the rifle resets each tick, so it
passed on the broken core too (1 vs 1). Rifle.handsRate now integrates the playing clip's real
advance per game second. Walking then firing (the Shoot clip the rifle does not re-time):
old core 2.83 (fail), fixed core 1.00 (pass), same served copy, core dist swapped.
Shooter hash re-pinned; scaffold spec 74/74; scaffold tsc 0.

* docs(prd-748): record the render-cost attribution, three fixes and the open structural boxes

* Engine raycast: a cached triangle BVH per geometry (PRD-752)

Mesh::raycast tested every triangle of the mesh. The shooter's merged town
meshes hold about 430k triangles, so a few soldier line-of-sight rays cost
about 90 ms in one update, which was the stutter left on the worker page.
The tree is built on first use, rebuilt when positions, index or drawRange
change, and skipped for morphed meshes and meshes that pose their vertices
(SkinnedMesh). Candidates run the unchanged hit code in triangle order, so
results stay identical: 2,048 oracle rays, 0 differ. 1,000 rays on a 20k
mesh: 8,471 ms -> 6.0 ms; a focused ray tests 4 of 20,000 triangles.

* feat(core): static meshes that share a material merge into one draw by default (PRD-748)

AutoBatch merges settled static meshes per material once the scene stops changing, hides the
sources instead of removing them, and gives a source back when the game moves, re-materials or
removes it (or an ancestor). Rays test the merged mesh's hierarchy and still name the authored
mesh. Named overrides, all reported by TN_AUTO_BATCH: userData.batch = "off", autoBatch: false,
autoBatch: { guard: "everyFrame" }. The shooter drops collapseTown.

* Worker mode: fix the review findings in the worker host, remote audio and profiler (PRD-752)

Worker: startup errors reject ready, stop removes the canvas and releases
pointer lock and capture, pointerlockerror, 0x0 clamp, HUD wheel stays on
the HUD, motion coalesced per page frame, one HUD snapshot in flight,
gamepads forwarded, a lazy playtest mirror with _clockEpoch.
Audio: the clock freezes while suspended, fluent AudioParam calls reuse
their handle, ended one-shots release theirs, numeric param defaults,
control ops settle from the page. Profiler: probes run in the game realm.
Each item has a red-green spec.

* profile:wasm-page --vsync keeps the browser's frame pacing for worker A/Bs (PRD-752)

The default flags unthrottle the page's requestAnimationFrame, not a worker's
OffscreenCanvas frames, so a worker arm read 37 ms frames against 10 ms on the
page. With vsync on, the shooter worker ran 16.7 ms frames against 33.3 ms on
the page thread. Also types the memory probe's realm (Page | Worker union).

* fix(playtest): the HUD audit composites a parent's background under the child's plate; kart race panels sit on plates (PRD-749)

Layer: playtest harness, plus the two kits it then judged correctly.
- sampleHudAudit walked up the DOM and painted each ancestor OVER the nearer plate, so an opaque
  page background hid every HUD plate. hud-audit-sampling.spec (browser-backed): red 100 vs 40,
  green. sampling + hud-audit specs 48/48.
- Kart: LAP, TIME, place and speed sat bare on the bright sky (orange at 1.45:1); each corner
  panel now sits on the kit's panel colour at 60%. Checked by eye on a played frame.
- Shooter: the legend separators rise from white/25 (2.12:1) to white/50.
hud-clean contract row, shooter + kart: FAIL/FAIL -> ok/ok. Kart and shooter hashes re-pinned;
scaffold spec 74/74.

* fix(core): auto batch merges at scene enter, and warm-up and projection skip hidden sources (PRD-748)

Merging at enter() draws no unmerged frame and lets the first warm-up count 589 merged meshes
instead of 8866 sources. A group's last giveBack no longer queues an upload for geometry it is
about to dispose (createBindGroup error on scene restart). Projection and warm-up ignore sources
the batch hides. Projection tests opt out with autoBatch: false.

* Playtest: a worker game relaunches without the unlimited frame rate (PRD-752)

Under a private Xvfb the runner adds --disable-frame-rate-limit so a page
game's frames escape the compositor's cadence. The same flag throttles a
worker's OffscreenCanvas frames (shooter: 36 ms presented with it, 16.7 ms
without). The runner now sees a worker game once the page opens and relaunches
once with that thread's flags; the report says gameThread: worker.

* PRD-752: the worker KPI box, measured with vsync on both kits

* profile:wasm-page spec: record the launch args without a comma expression (PRD-752)

* Playtest: a live-clock hold counts the ticks the game ran, not the requests (PRD-752)

A wall-clock advance answers a one-tick request with the ticks the host's pump
ran: two per frame at 30 fps on a 60 Hz step. advanceFixedStep counted
requests, so a 169-tick key hold ran about 338 ticks and the starter's player
overshot into the gap on the worker page. The bridge client now returns the
reported count and the loop counts it; a fixed-step bridge still runs exactly
one tick per request. Starter play passes on / (worker) and /main.html.

* PRD-752: starter and harness boxes ticked; worker-in-Xvfb and live-clock hold decisions

* PRD-752: visual judge NEUTRAL ticked; shooter performance on the real display matches the page thread

* PRD-752: no worker-only frame rate under Xvfb (scheduler probe, draw-call evidence)

* PRD-752: phase 4 ceiling measured (shooter about 6%, Midway about 25% splittable engine work)

* PRD-752: phase 4 threaded-build probe (1-5% single-thread cost); job pool waits on PRD-573 v3.1

* PRD-752: threaded-build cost re-measured on one source tree (about 1.5-3%); earlier A/B void

* docs(prd-748): tick the loading-cover box with the shooter page and worker proof

* fix(kits): adventure, collectathon and rain HUD text reaches 3:1 (PRD-749)

The hud-clean kit-contract row failed on four kits of the merged #574 branch.
Direct headed WebGPU runs (NVIDIA adapter), worst ratio before -> after:

- adventure: keys count "0" (text-lume) 2.33 -> 3.70, chest hint
  (text-text/70) 2.70 -> 3.90. HUD panel and toast bg-panel/40 -> /60.
- collectathon: coin, gem and sunseed counts (text-lume) 2.15 -> 3.58.
  HUD pill bg-panel/25 -> /50 (gems still show through), toast /40 -> /60.
- rain: compass letters "NW 355° NE" 2.75 -> 3.88. .compass colour alpha
  0x60 -> 0x80.
- minimal: TN_PLAYTEST_HUD_AUDIT_UNOBSERVED is true: the kit draws no DOM
  text during play (main.ts only prepends the canvas). It opts out of
  hud-clean with a written reason.

Scaffold hashes re-pinned for adventure, collectathon and rain.

* Starter-kit authoring loop, one KPI dashboard, reverse-engineer references (PRD-750/751/735) (#575)

* docs(prd-750): plan the kit authoring loop, one KPI dashboard (PRD-751) and the reverse-engineer rename (PRD-735)

Owner interview 2026-10-26: one iteration = in-repo kit author + cold sandbox build, retro on both
transcripts; KPIs K1 cold-build cost, K2 first-build quality, K3 friction, K4 AI play grade; ranked
genre list; PRD-735 lands first and renames threenative-game-refs to threenative-reverse-engineer-mcp;
perf:live becomes the single pnpm dashboard; one epic PR.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PRD-751 Phase 1: pnpm dashboard is the one KPI hub with CI speed and Asset perf tabs

perf:live becomes pnpm dashboard. The CI speed and Asset perf tabs render their
ledgers on each load (missing = pending, malformed = invalid). The two static
docs/*/index.html pages, the loops' report/serve commands and their consumers go.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PRD-735 AC-0: references come from ThreeNativeHQ/threenative-reverse-engineer-mcp

The data repo moved and was renamed (the old release URL redirects). The engine
MCP reads the new release and files contributions there. A request that asks to
reverse engineer a game leads with the three reference tools, and their
descriptions carry the words.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PRD-750 Phase 1 + PRD-751 Phase 2: pnpm kit:loop record and the Kit loop tab

scripts/kit-loop.mjs turns a cold-build dir (transcript, gates, friction, play
grade, retro) into one ledger row with K1-K4; any missing input fails closed.
Row 1 is the baseline and sets the K1/K3 targets. The hub renders
docs/kit-loop/ledger.json as the Kit loop tab, and the native-performance-loop
skill now adds a hub tab instead of a per-campaign page. PRD-751 is done.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* PRD-735: tick AC-1 (Tier 2 import, 3287 rows) and AC-3 (OoT 17 reviewed cards) with the data repo proof

* PRD-750 iteration 1: baseline ledger row 1 (collectathon, SM64 cold build: K1 97,840 tokens, K2 21/22 + CLOSE, K3 7, K4 4); dashboard default port 4751

* fix(playtest): a cost bound failed beside other GPU processes names them and says rerun, not cut the game (PRD-750 retro friction 1)

Kit-loop row 1: the cold agent spent ~25 turns lowering the high tier's look to pass a
performance gate that two foreign Playwright GPU processes were loading, while the capture
lock printed 'no competing runner detected'. foreignGpuProcesses() reads /proc for Chromium
GPU processes outside this run's tree; the failure stays a failure (fail closed), its message
names them and its suggestion says to rerun when they exit.

* fix(create-threenative): the scaffold's last line says to start the agent in the project so .mcp.json loads the engine MCP (PRD-750 retro friction 2)

Kit-loop row 1: the cold agent scaffolded from its session folder, so the engine MCP that the
project's .mcp.json wires never loaded and it searched no capability (it grepped agent-docs).

* feat(engine-mcp): one tool call from the command line for a session that never loaded .mcp.json (PRD-750 retro friction 2)

node node_modules/@threenative/core/mcp/engine.mjs <tool> '<json>' prints the tool text (exit 0)
or names the error (exit 1). The scaffold's last line names it beside 'start your agent in the
project'. Red-green: server.spec 'command line', scaffold.spec completion.

* docs(prd-750): AC-5 notes: row 1's two retro fixes landed, row 2 pending

* fix(engine-mcp): type runCli's io sink as void so the spec typechecks; rebundle

* fix(playtest): --scenario takes a shell brace list (playtests/{a,b}.playtest.json) (PRD-750 row 2 retro)

Kit-loop row 2: the cold agent quoted a brace list and got TN_PLAYTEST_SCENARIO_UNREADABLE, then
reran the whole suite. segmentMatches now reads one level of {a,b}. Red-green: bridgeClient.spec.

* fix(create-threenative): the scaffold's last line says CLAUDE.md copies AGENTS.md, read one (PRD-750 retro friction 3)

Rows 1 and 2 both read the 45 KB CLAUDE.md + AGENTS.md pair, then AGENTS.md again. Red-green:
scaffold.spec completion.

* PRD-750 iteration 2: ledger row 2 (K1 44,483 tokens -55%, K2 22/22 + CLOSE, K3 2, K4 4) names row 1's fixes; AC-5 notes

* PRD-750: ledger row 3 (K1 47,107, K2 22/22 + CLOSE, K3 0, K4 4); tick AC-4 (rows 2-3 gate-green cold builds on the kit); rows 4..N blocked on the PRD-749 kit base

* fix: review findings on the PRD-750 retro fixes

- the scaffold's engine fallback names the project's absolute path (a session started in the
  parent folder got MODULE_NOT_FOUND from the relative one)
- foreign GPU processes are Playwright Chromium only (an editor's or desktop browser's GPU process
  made every real over-budget failure read as contended)
- an unmeasured cost is never called contended (the suggestion said rerun beside 'cannot be judged')
Red-green: frame-budget.spec, scaffold.spec completion.

* test: authoring-layer regression guards from the PRD-750 kit loop

- every lookup word of every shipped kit routes 'make me a game like <word>' to its own kit first
- the core engine shim answers one tool call from a folder outside the project (exit 0,
  collectathon first) and an unknown tool exits 1 with no stdin hang

* fix(kit-loop): K2 reads met only with all scenarios green, the frame budget met and a CLOSE verdict

The Kit loop tab marked K2 met on rows 2-3 from 22/22 scenarios while their frame budget was over.
Red-green: kit-loop.spec.

* PRD-750: K2 frame budget is the performance gate's cost p95 (owner delegated); rows 1-3 recomputed

Presented p95 on the runner's private Xvfb is display cadence (17.5 ms with a 10 ms game). gates.json
now carries costP95Ms (fails closed when missing); rows 2-3 meet the budget at 10.03 and 12.59 ms,
row 1 stays over at 17.27 ms. Red-green: kit-loop.spec.

* ci(prd-750): record the PRD-735 reference-tool descriptions and allow the kit-loop KPI id in the full-history gitleaks scan

* Hook reference search to the new data repository and its release shape (#579)

* chore(refs): point references at ThreeNativeHQ/threenative-reverse-engineer-mcp [skip ci]

The data repository moved from jonit-dev/threenative-game-refs to
ThreeNativeHQ/threenative-reverse-engineer-mcp. The old name still redirects,
but search, the contribution command and the docs should name the canonical
repository. The default URL already tracks `releases/latest`, so published
data keeps flowing without a version pin.

`packages/core/mcp/engine-server.mjs` is regenerated from the engine-mcp
build; its only delta is the same two constants.

* fix(refs): read the catalog fields a reference release now carries [skip ci]

`index.json.gz` game rows gained `kind`, `rating`, `genre` and `subgenre`, and
the strict row whitelist rejected the whole release with `TN_REFERENCES_MALFORMED`
— the engine could not read any release published after the catalog grew those
columns. Search never reads them, so the whitelist accepts them and drops them;
a field the engine does not use must not gate a data release.

Templates also name classic-game mechanics references next to capability search in
the authoring skill list, so a scaffolded agent sees both discovery paths.

Proof against the published v0.2 release:
`THREENATIVE_REFERENCES_URL=https://github.com/ThreeNativeHQ/threenative-reverse-engineer-mcp/releases/download/v0.2/ pnpm exec vitest run packages/engine-mcp/__tests__/references.spec.ts`
(28 passed, live release download included).

* fix(playtest): the HUD audit compares nodeType to 3, not the browser-only Node global (native shim gate)

* fix(create-threenative): a game named with --genre opens its next steps with a ready reference search (PRD-750, sandbox-4 made no engine call)

* fix(create-threenative): --genre runs the reference search and prints the top mechanics; a ready command alone was skipped (PRD-750 sandboxes 4-5)

* docs(prd-750): record the reference-trigger probes (sandboxes 4-6)

* style(create-threenative): sort the scaffold spec imports

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>

* fix(core): auto batch keeps a still parent merged when its children hold their own matrices (PRD-748)

stampMatches reuses one scratch buffer, and writeStamp skipped the matrix slots for an object
with matrixAutoUpdate on, so they kept the last stamped object's matrix. The shooter turns
matrixAutoUpdate off on its town pieces (sight boxes), so the town group read as moved and the
guard gave back all 8307 sources: 13838 draws, CPU p95 89 ms. writeStamp now zeroes the slots.
auto-batch.spec 'keeps a still parent merged...' red (and 3 later tests red through the shared
buffer), green after; auto-batch + game + constraints 93/93.
Shooter performance.playtest, headed WebGPU, real display: no give-back; draws median ~150
(162 before auto batch, collapseTown), one 774-draw frame at the window start (several 796-draw
frames before). maxDrawCalls 320 still fails on that startup spike, which predates this lane.

* restart-resets: bound running clocks with atSteps lte; platformer gets an R restart (PRD-749)

A restart settles 30 ticks before the row samples, and racing elapsed (Race.ts:327),
sailing elapsed (Sailing.ts:255) and runner distance (Run.ts:175) keep running in that window,
so 'equals 0' could never hold (0.517 s, 0.517 s, 5.16 m observed). Resource atSteps takes lte;
contract.json bounds them below the dirty run (2.0 s, 3.0 s, 22 m).
Platformer had no restart key at all (the row pressed an unbound KeyR): it now binds R like
kart/puzzle and shows the hint. Its spawn sits on the first coin (Play.ts:218 vs Stage.ts:52), so
a fresh run reads coins 1; the reset check uses the run clock instead.

* explore-clean: judge the path walked, not the net displacement (PRD-749)

Starter's seeded explorer walked 63 m and 47 m of odometer, jumped 7 times and fell off three
times; the last life lost respawned it on the spawn point, so net distance read 0.003 m and the row
called a moving subject stuck. A path is never shorter than the net distance, so no kit that passes
now can fail, and step-boundary samples keep physics jitter from counting as travel.

* action-clip-completes: judge each clip at its own step; action-rpg publishes its animation and no longer cuts the swing (PRD-749)

Three faults stacked behind TN_PLAYTEST_ANIMATION_NOT_OBSERVED on action-rpg:
- harness: assert.animation read only the last sample, so a kit with three actions could never
  pass; it takes atStep now (labeled gameplay series, as footSlip does) and the row uses it.
- kit: no entity published 'animation' (the bridge reads entity.animation, see shooter Enemy.ts:758),
  so nothing was observed. Fighter now publishes the last one-shot action and whether it ran its
  whole clip (a flinch is a reaction and never replaces it).
- kit: the swing held 0.8 s of a 1.533 s Sword_Attack clip (Player.ts SWING), so the follow-through
  was cut at 52 percent (finished false). It is fitted like the roll; the blade lands at
  SWING_CONTACT / rate, 12.5 ticks, so contactTicks reads 13. Declared ticks are the hold the kit
  keeps (48, 42, 42), not the file length: the cast holds 0.7 s, and the roll press at 36 ticks was
  refused while the cast was still busy.

* scaffold spec: re-pin action-rpg and platformer hashes after the restart and clip fixes

* fix(playtest): sightLine no longer counts cutouts, effect volumes or ropes as walls

A cutout (alphaTest), a transparent surface that writes no depth and a sliver the
ray crosses in under 15 cm do not hide the subject. Walls, glass and a camera
inside a solid still block.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(kits): the camera no longer hides the player in starter, platformer, puzzle, action-rpg and adventure

starter and platformer pull the lens in front of a solid (ctx.raycast); puzzle and
action-rpg cut the wall nearest the lens to a kerb; adventure's camera arm also
tests the roots and boulders the physics world does not hold.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>

* fix(playtest): a sliver is a rope or a rail only, a thin panel still hides the subject

The sliver rule skipped any solid the ray crossed in under 15 cm, so an 8 cm
panel stopped counting as a wall. It now also needs the mesh to be thin on two
axes. Spec red (panel not blocked) -> green. The reference now says one ray, as
the code casts.

* action-rpg swing scenario: the fitted swing lands near tick 10, so the landing step starts after one spam press (PRD-749)

* scaffold spec: re-pin action-rpg, adventure, platformer, puzzle and starter after the PRD-749 row lanes

* style: biome format the camera lane's kit files; re-pin platformer, puzzle and starter

* docs(prd-748): auto batch box progress: census numbers and the NEUTRAL judge verdict

* docs(prd-749): contract suite box ticked (16 kits, opt-out rule spec); seed rows progress on 6ef214594

* test(native-engine): the offscreen GPU timer sample has three passes since a render target records no output pass (a0ecc6f0d)

* docs(native): refresh the native coverage record after the develop merge

* docs(prd-749): platformer camera row experiments and why none shipped

* build(web-engine): the ray BVH and geometry generators build at -Os on web, so the engine fits its 4 MiB budget again

The merged branch read 4,200,028 bytes in CI (allowed 4,194,304). Native keeps -O3.
Web engine now 4,145,430 bytes.

* fix(test): lift the camera-cut exposure rooms over the black bucket (PRD-747)

PRD-747 (4a8cb1cd4) drops meter blocks under Unreal's -10 EV floor. The
fixed camera-cut rooms put the dark room at about -9.7 log2 with its
backdrop under -10, so the meter ignored part of it and an 11-stop cut
read 9.75 stops (verifySnap: terminal target does not match the cut).
Both rooms now sit 3 stops higher; the 11-stop ratio, lights and
assertions are unchanged. Local snap proof: cut 11.003 stops, gain-0
negative control still fails as expected.

* perf(core): auto batch counts scene adds and removes from graph events, not by reading children each frame

The pass polled the scene's shape every frame by reading scene.children and each child's
children. On the native web engine every children read wraps each child across the bridge,
so the shooter town's 8,000 meshes cost about 10 ms of a 21 ms worker frame (owner trace
2026-10-27: 40% of worker CPU). three's childadded/childremoved events now count the same
change; a still frame reads no children list. Paired DevTools traces on the shooter route:
worker frame interval p50 22.2/23.1 -> 16.7/16.7 ms, p95 29.9/28.9 -> 16.9/17.7 ms.

* perf(native-engine): the renderer's batch decision leaves hidden subtrees out

RenderDatabase::batch re-decides its projection every 60 frames when the scene holds a
CPU-skinned rig. decide() walked and grouped every mesh, hidden ones too, so the 8,300
sources core's auto batch keeps hidden in the shooter town cost a 25 ms Wasm stall once a
second (two missed vsyncs). The renderer now decides on what it draws (decide drawnOnly);
hostProjectionReport keeps the JS-parity walk. plan_test: 341 sources counted before, 40 after.
Paired DevTools traces on the shooter route (auto-batch fix in both arms): frames over 34 ms
10/6/9 -> 1/3, worker interval p99 37.3/37.6/36.2 -> 19.7/24.4 ms. Web Wasm 4,145,393 bytes.

* perf(core): the five-second projection window line no longer walks the scene

formatProjectionWindow never printed resultDrawCandidates, yet each window read the full
report, whose count walks the whole scene. On the native web engine that walk crosses the
bridge per node: 8,900 nodes in the shooter town, 13-16 ms on the window frame, a missed
vsync every five seconds. SceneRenderProjection.windowReport is the report less that one
field; report still counts it. Paired traces: window-frame report cost 14.6/12.9/15.6 ms
-> not measurable (under 3 ms) in 2/2 runs.

* style(core): sort the projection spec imports

* fix(test): lift the plain exposure room one stop over the black bucket (PRD-747)

PRD-747 drops meter blocks under Unreal's -10 log2 floor. The plain
fixture room's dark backdrop metered -10.07, so dark-adapted read
0.0031594 against the pinned 0.0013968 (TN_EXPOSURE_METER_RANGE).
The room now sits one stop higher; three stops washed the unadapted
fixed-exposure frame to a blank white (TN_CAPTURE_BLANK). The backlit
patches keep their 10 and 14 stops over the room. Re-measured pins
(turing, local): dark 0.0027937 (2.000x), sunlight 5.7215 (2.001x).
Tolerances and assertions are unchanged.

* feat(core): the frame meter reports drawn frames beside presented

The web engine's frame gate declines a draw while two frames are on the GPU.
TN_FRAME_BUDGET now carries drawn (interval between drawn frames), drawnFrames
and drawnFps next to presented, so every window line shows the frames the
player lost (shooter: 270 presents against 293 rAF ticks while presented p95
read 16.8 ms). presented and fps are unchanged, so no gate changes its verdict;
gating on drawn frames moves to PRD-752 phase 4 with its Xvfb note.

* fix(test): core spec fixtures match the projection window and drawn-frame types

render-camera-cull passed resultDrawCandidates, which ProjectionWindowReport omits
since 9e38c0edf; scene-warning's window lacked the drawn fields from da486c188.
Root tsc failed on both.

* docs(agents): make performance mistakes painful to write: engine first, fail closed, then a kit lint rule

Owner, 2026-10-27. The root rules and the game-facing performance skill name the order: the engine makes the
pattern cheap, then fails closed with the call site, then the kit quality ESLint plugin rejects the rest. Kit
hashes re-pinned for the skill text.

* fix(core): the worker's six unknown casts carry their waiver reasons

The quality gate (scripts/__tests__/quality-json.spec.ts) failed on CI run 40576902353: six
suppression-class casts from worker mode (PRD-752) had no reason. Each one now says why the type
cannot be expressed.

* fix(native-engine): the pruned matrix walk defers a hidden node's own compose

Core's MatrixWorldPass contract: a hidden node defers its compose and its subtree
until shown. The native walk composed the hidden node itself, so the 8,300 hidden
auto-batch sources of the shooter paid a compose every frame. A camera and a node
holding bones keep composing.

Red-green: scene_hierarchy_test pruned_walk fails before (lines 1396-1397), passes
after; ctest 168/168 on build/wasm. Web Wasm 4,145,416 bytes (budget 4,194,304).
Shooter worker, MCP Brave, vsync on, load 16-23, sceneUpdate span mean
A 2.40/2.06 ms -> B 0.43/0.43 ms, p95 4.0/3.0 -> 0.8/0.7 ms.

(cherry picked from commit dca708bd49ac0df2f31e883247d4deb0a4ce5a29)

* perf(templates): the backlight graph reads its controls once per render

Each of the 8 uniform onRenderUpdate callbacks validated the controls, and
keyDirection/keyGain each resolved the key: 17 control reads per render, 525
bridge reads per frame on the shooter. One validate and one key resolve per
render now feed every uniform; the values each uniform gets are unchanged.

Red-green: backlight-shared-graph 'one render reads the controls once' read
rimGain 17 times before, <= 5 after. Scaffold hashes re-pinned for the 12 kits
(77 specs pass). Shooter worker trace, MCP Brave, vsync on: validateControls +
resolveKey inclusive 0.93 -> 0.48 ms per frame (profiler on).

(cherry picked from commit 22c706b69f3b5a0ffbb221188ca17c0d6d152e11)

* docs(native): refresh the native coverage record after the matrix walk and batch decision changes

* fix(build): the desktop UI build bundles src/ui alone, not the kit's main.html game

A worker kit's vite.config.ts names index.html and main.html as inputs. The UI build
driver merged its own input into that config and then let vite load the config file a
second time, so main.html bundled the whole game and its Wasm engine into the HUD
build: TN_WASM_ENGINE_MISSING on a desktop build that never needs the web engine.

(cherry picked from commit 9e3aab6d605b124fcb70c37e07a42e72a1dcd743)

* fix(test): the hot-reload lane proves the starter's page-thread entry, main.html

PRD-752 made index.html run the starter in a worker. The provenance sweep then waited for
a WebGPU context on a canvas handed to an OffscreenCanvas, and the spec's synchronous
__THREENATIVE__.hot() reads met the worker's async mirror. State-keeping hot reload lives
in src/main.ts only: Vite cannot hot-update a worker, and once the worker page loads, every
game edit fully reloads every page of that server. After an update the restarted game's
HUD reads nothing until its startup cover lifts (PRD-748), so the wait includes the score.

(cherry picked from commit 6f6e1cd630ac49770e20da0299fba5281fd7be59)

* docs(perf): record the 2026-10-27 shooter campaign levers and the crossing, meter and 60 Hz lessons

* fix(budgets): worker-only browser globals carry native allowlist reasons; shooter convention cells point at the moved calls

CI run 40584406363 failed budgets: TN_NATIVE_SHIM_MISSING for MessageChannel, queueMicrotask and
EventTarget, read only by the browser worker page (PRD-752) and the ?tnUiLoopback=1 loopback, and
three PRD-289 table cells that the shooter revamp had moved. pnpm budgets now exits 0.

* perf(three-native): the web engine keeps what game code reads again, and names what it cannot

On the default web engine every read of an engine property crosses JS to Wasm. The shooter's
backlight read the key light's intensity 450 times and its colour 225 times a frame, and walks
read children and parent the same way. The back end now keeps those answers, so game code that
is free in plain three.js is free here too:

- JS-written properties (labels, render flags, light and fog parameters, material flags and map
  slots) stay in a JS mirror until their own setter or a method on the object runs.
- Mixer-animated numbers (light intensity, camera fov/zoom/near/far, material opacity and the
  PBR numbers) stay mirrored until any AnimationMixer/AnimationAction call, which writes them.
- children, parent and a whole-subtree walk stay until Object3D::hierarchyVersion moves. The
  engine-internal getter __hierarchy gives the counter's address once, so the check is a memory
  read, no crossing.
- Layers.test runs in JS on the two in-memory masks.

What the cache cannot keep, a census frame per 120 safe points counts by property and names the
caller (bridge-meter.ts); past 1/50 of the frame it prints TN_BRIDGE_WARNING, which a playtest
fails on. __THREENATIVE_BRIDGE_CACHE__ = false turns the cache off: the paired A/B knob.
The hot-callback-scene-work lint rule now also covers TSL onRenderUpdate/onFrameUpdate/
onObjectUpdate, setAnimationLoop and array-iterator callbacks inside a hot callback, and flags
iterating .children there (the AutoBatch fingerprint shape).

Red-green: bridge-reads.spec.ts 4/7 red on the old back end, 7/7 now; quality-rules 2 cases red
on the old rule; perf-warnings bridge case red on the old evaluator. Real Wasm: smoke and GC
TN_BROWSER_BACKEND_OK / TN_BROWSER_GC_OK. Web Wasm 4,146,105 B (budget 4,194,304).
Paired shooter census (unfixed kit, worker, NVIDIA turing, headed :0): 2,022 -> 661 crossings
per frame. Route A/B, 3 runs per arm: crossings 3,249/3,092/3,241 -> 1,557/2,228/1,641 per
frame, frame p50 9.95/9.55/10.0 -> 9.05/8.05/8.9 ms, p95 21.05/16.5/19.55 -> 14.75/11.65/13.45 ms.

(cherry picked from commit b088dc46f7249705aa5e3fad8daaea4ec1acdd5d)

* perf(core): the camera pass keeps its rig and camera walks while the engine's hierarchy holds still

The animation-rate prep rebuilt its rig map (a full walk under every rate root)
and the camera-part set (a walk under the camera) every frame, each walk one
wrapper per node across the web engine boundary. The native engine already
counts every add, remove, attach and destroy (Object3D::hierarchyVersion); it
is now readable as the engine-internal getter __hierarchyRevision, and both
walks run again only when it moves or the roots or their states change. Plain
three has no count and walks every frame as before.

Red-green: animation-rate 'keeps its rig and camera walks' fails on the old
cull (3 walks expected, 4 seen), passes now; abi_test hierarchy_revision fails
without the getter, passes with it; registry snapshot regenerated, --check 0;
core 4241 pass, three-native 390 pass. Web Wasm 4,146,142 B (budget 4,194,304).
Probe: 2 rebuilds in 600 calls on the shooter route (6 rigs).
Shooter on the laptop (Iris Xe, --disable-gpu-vsync --disable-frame-rate-limit,
load 5-9, 98 C), 3 pairs: cul…
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

prd:25% PRD phase 1 verified

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant